"""
ForgeFed plugin for Pagure.
Copyright (C) 2020-2021 zPlus <zplus@peers.community>

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.

SPDX-FileCopyrightText:  2020-2021 zPlus <zplus@peers.community>
SPDX-License-Identifier: GPL-2.0-only
"""

import copy
import datetime
import json
import logging
import pagure.config
import pagure.lib.model
import pagure.lib.query
import urllib

from Crypto.PublicKey import RSA
from pagure.lib.model_base  import BASE
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \
                       LargeBinary, UnicodeText, func
from sqlalchemy import PrimaryKeyConstraint
from sqlalchemy import create_engine
from sqlalchemy.orm import relationship
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm.session import Session

from . import APP_URL
from . import activitypub
from . import settings

log = logging.getLogger(__name__)

def safe_uri(uri):
    """
    urlencode a Actor URI such that it can be used in Pagure safely. This is used
    when creating users or projects for mapping remote actors.
    """

    return uri.replace('/', '_') \
              .replace(' ', '_')
    #return urllib.parse.quote(uri, safe='')

def from_uri(database, uri):
    """
    In ActivityPub, objects' ID are URI and occasionally we need to retrieve an
    object from the Pagure database given only its URI. If pagure were using
    a graph as a database, this would be trivial (just query "DESCRIBE <uri>").
    Also, the plugin does not attempt to mirror the entire pagure database into
    a graph; instead objects' documents (the JSON-LD that is returned when GETting)
    are created "on the fly" for every request. We could add the Pagure IDs to the
    JSON-LD object, as a custom property, but it would be very inelegant and hacky.
    We could add URIs to the Pagure database, but it would require to change the
    Pagure database schema. So we need a way, given only a URI, to reverse it and
    get the object in the database. This function does that.

    :param database: A session context to the Pagure database.

    :param uri: The URI to reverse.
    """

    log.debug('Retrieving database object from URI {}'.format(uri))

    uri_path = urllib.parse.urlsplit(uri).path

    # Check if it's an URI of a remote object
    if not uri.startswith(APP_URL):
        # Get the URI of the local object that maps the remote object
        same_as = database \
            .query(SameAs) \
            .filter(SameAs.remote_uri == uri) \
            .one_or_none()

        if not same_as:
            return None

        uri = same_as.local_uri

    log.debug('Retrieving database object for URI {}'.format(uri))

    # Fetch the JSON-LD document of the object
    try:
        object = activitypub.fetch(uri)
        assert object
    except:
        return None

    if object['type'] == 'Person':
        return database.query(Person) \
                        .filter(Person.user == object['preferredUsername']) \
                        .one_or_none()

    if object['type'] == 'Project':
        """
        Pagure uses 2 kinds of URL for repositories:
            /<repo>
            /<namespace>/<repo>
        and other 2 for forks:
            /fork/<username>/<repo>
            /fork/<username>/<namespace>/<repo>
        """

        components = uri_path.lstrip('/').split('/')

        repo_username  = None
        repo_namespace = None
        repo_name      = None

        if len(components) == 1:
            repo_name = components[0]
        if len(components) == 2:
            repo_namespace, repo_name = components[0], components[1]
        if len(components) == 3:
            repo_username, repo_name = components[1], components[2]
        if len(components) == 4:
            repo_username, repo_namespace, repo_name = components[1], components[2], components[3]

        project = database.query(Project).filter(Project.name == repo_name)

        if repo_username:
            project = project.filter(Project.is_fork == True) \
                             .filter(Project.user.has(Person.user == repo_username))
        else:
            project = project.filter(Project.is_fork == False)

        if repo_namespace:
            project = project.filter(Project.namespace == repo_namespace)

        return project.one_or_none()

    if object['type'] == 'Repository':
        project = from_uri(database, object['project'])

        return database.query(Repository) \
                        .filter(Repository.id == project.id) \
                        .one_or_none()

    if object['type'] == 'TicketTracker':
        project = from_uri(database, object['project'])

        return database.query(TicketTracker) \
                        .filter(TicketTracker.id == project.id) \
                        .one_or_none()

    if object['type'] == 'Ticket':
        # The Pagure URL for issues is "<project>/issue/<number>"
        project_uri, _, issue_id = uri.rsplit('/', 2)
        project = from_uri(database, project_uri)

        if not project:
            return None

        return database.query(Ticket) \
                        .filter(Ticket.id == issue_id,
                                Ticket.project_id == project.id) \
                        .one_or_none()

    if object['type'] == 'MergeRequest':
        # The Pagure URL for pull-requests is "<project>/pull-request/<number>"
        project_uri, _, mergerequest_id = uri.rsplit('/', 2)
        project = from_uri(database, project_uri)

        if not project:
            return None

        return database.query(MergeRequest) \
                       .filter(MergeRequest.id == mergerequest_id,
                               MergeRequest.project_id == project.id) \
                       .one_or_none()

    if object['type'] == 'Note':
        if uri_path.startswith('/federation/ticket_comment/'):
            comment_id = uri_path.rsplit('/', 1)[-1]
            return database.query(TicketComment) \
                            .filter(TicketComment.id == comment_id) \
                            .one_or_none()

        if uri_path.startswith('/federation/mergerequest_comment/'):
            comment_id = uri_path.rsplit('/', 1)[-1]
            return database.query(MergeRequestComment) \
                            .filter(MergeRequestComment.id == comment_id) \
                            .one_or_none()

    # No object found
    return None

def from_path(database, path):
    return from_uri(database, '{}{}'.format(APP_URL, path))

def test_or_set_user(database, actor_uri):
    """
    We want to use the existing pagure UI to collaborate with remote users, for
    example to hold a conversation on a Ticket. Problem is, the pagure database
    model is built around *local* users that have IDs and relations with other
    schemas in the database. So, we create a local mock user that represents a
    remote one, and call it user@domain.

    :param database: A session to the pagure database.
    :param actor_uri: The URI of the remote actor.
    """

    # This URI is a local actor
    actor = from_uri(database, actor_uri)
    if isinstance(actor, Person) and actor.is_local:
        return actor

    # Fetch JSONLD of the remote actor
    actor = activitypub.fetch(actor_uri)

    # We need to create fake data (username/password) for the new user
    name = '{}@{}'.format(
        safe_uri(actor['preferredUsername']),
        safe_uri(urllib.parse.urlparse(actor_uri).netloc))

    email = '{}@{}'.format(
        safe_uri(actor['id']),
        safe_uri(urllib.parse.urlparse(APP_URL).netloc))

    ssh_keys = []
    if 'sshKey' in actor and isinstance(actor['sshKey'], list):
        for key in actor['sshKey']:
            try:
                key = activitypub.fetch(key)
                ssh_keys.append(key['content'])
            except Exception as e:
                log.error('Cannot add SSH key to User')
                log.error(e)

    # This will create a new user in the Pagure database if it doesn't exist,
    # or update the existing one. Returns the User object.
    user = pagure.lib.query.set_up_user(
        session       = database,
        username      = name,
        fullname      = name,
        default_email = email)

    # Add the SSH keys
    # Multiple keys are joined with "\n", that's how Pagure parses them.
    # Calling this function will delete all existing keys and add the new ones.
    pagure.lib.query.update_user_ssh(
        database, user.username, '\n'.join(ssh_keys), keydir=None)

    # Get a Person object instead of the pagure User class
    person = database.query(Person) \
                     .filter(Person.id == user.id) \
                     .one_or_none()

    # Set a SameAs link, so that we know that this person is only used to
    # represent a remote user.
    database.merge(SameAs(
        local_uri  = person.local_uri,
        remote_uri = actor_uri))

    database.flush()

    return person

def test_or_set_forgefed_user(database):
    """
    We use an instance-wise user called "forgefed@<APP_URL>" for various tasks
    that are required by federation but are not associated with any other user.
    For example for signing HTTP requests for forwarded Activities, or as the
    "maintainer" of local copies of remote objects (repositories, ticket trackers).
    """

    forgefed_user = 'forgefed@{}'.format(safe_uri(urllib.parse.urlparse(APP_URL).netloc))
    return pagure.lib.query.set_up_user(
        session       = database,
        username      = forgefed_user,
        fullname      = forgefed_user,
        default_email = forgefed_user) # Unfortunately "email" is required

def get_person_roles(person):
    """
    Return all the access roles of a person.
    """

    database = person._get_database_session()

    return database \
        .query(Role) \
        .filter(Role.user_id == person.id) \
        .all()

def get_group_roles(group):
    """
    Return all the access roles of a group.
    """

    database = group._get_database_session()

    return database \
        .query(ProjectRole) \
        .filter(ProjectRole.group_id == group.id) \
        .all()

class ActivityStreamObject:
    """
    An ActivityStrem Object.
    """

    def _get_database_session(self):
        """
        Return the SQLAlchemy session associated with this instance.
        """

        return Session.object_session(self)

    @property
    def uri(self):
        """
        The actual URI of this ActivityPub object.
        """

        remote_uri = self.remote_uri
        local_uri  = self.local_uri

        if remote_uri: return remote_uri
        if local_uri:  return local_uri
        return None

    @property
    def remote_uri(self):
        """
        The URI of the remote object if this object is only a local placeholder
        for a remote object.
        """

        db = self._get_database_session()
        same_as = db.query(SameAs) \
                    .filter(SameAs.local_uri == self.local_uri) \
                    .one_or_none()

        return same_as.remote_uri if same_as else None

    @remote_uri.setter
    def remote_uri(self, uri):
        """
        Set remote URI if this object is just a local copy of a remote one.
        """

        db = self._get_database_session()
        db.merge(SameAs(local_uri  = self.local_uri,
                        remote_uri = uri))

        db.flush()

    @property
    def local_uri(self):
        """
        The URI of the local object on this instance of an ActivityPub object.
        """

        raise NotImplementedError

    @property
    def is_local(self):
        return self.remote_uri is None

    @property
    def is_remote(self):
        """
        Return True if this object is a local copy of a remote object. An example
        of such object would be a comment. A local copy is created such that users
        on the local instance can use the Pagure UI to interact with the remotes.
        """

        return not self.is_local


################################################################################
# Extension of the Pagure database model
################################################################################

pagure.lib.model.User.__bases__ += (ActivityStreamObject,)
pagure.lib.model.User.local_uri = pagure.lib.model.User.full_url
pagure.lib.model.User.test_or_set = staticmethod(test_or_set_user)
pagure.lib.model.User.roles = property(lambda self: get_person_roles(self))
Person = pagure.lib.model.User

pagure.lib.model.PagureGroup.__bases__ += (ActivityStreamObject,)
pagure.lib.model.PagureGroup.local_uri = pagure.lib.model.PagureGroup.full_url
pagure.lib.model.PagureGroup.roles = property(lambda self: get_group_roles(self))
Group = pagure.lib.model.PagureGroup

pagure.lib.model.ProjectUser.__bases__ += (ActivityStreamObject,)
pagure.lib.model.ProjectUser.local_uri = property(lambda self: '{}/role/{}'.format(self.project.uri, self.access))
Role = pagure.lib.model.ProjectUser

pagure.lib.model.ProjectGroup.__bases__ += (ActivityStreamObject,)
pagure.lib.model.ProjectGroup.local_uri = property(lambda self: '{}/role/{}'.format(self.project.uri, self.access))
ProjectRole = pagure.lib.model.ProjectGroup

pagure.lib.model.SSHKey.__bases__ += (ActivityStreamObject,)
pagure.lib.model.SSHKey.local_uri = property(lambda self: '{}/sshkey/{}'.format(self.user.local_uri, self.ssh_search_key))
SshKey = pagure.lib.model.SSHKey

pagure.lib.model.Project.__bases__ += (ActivityStreamObject,)
pagure.lib.model.Project.local_uri = pagure.lib.model.Project.full_url
Project = pagure.lib.model.Project

pagure.lib.model.TagColored.__bases__ += (ActivityStreamObject,)
pagure.lib.model.TagColored.local_uri = property(lambda self: '{}/federation/tag/{}'.format(APP_URL, self.id))
Tag = pagure.lib.model.TagColored

pagure.lib.model.Issue.__bases__ += (ActivityStreamObject,)
pagure.lib.model.Issue.local_uri = pagure.lib.model.Issue.full_url
Ticket = pagure.lib.model.Issue

pagure.lib.model.IssueComment.__bases__ += (ActivityStreamObject,)
pagure.lib.model.IssueComment.local_uri = property(lambda self: '{}/federation/ticket_comment/{}'.format(APP_URL, self.id))
TicketComment = pagure.lib.model.IssueComment

pagure.lib.model.PullRequest.__bases__ += (ActivityStreamObject,)
pagure.lib.model.PullRequest.local_uri = pagure.lib.model.PullRequest.full_url
MergeRequest = pagure.lib.model.PullRequest

pagure.lib.model.PullRequestComment.__bases__ += (ActivityStreamObject,)
pagure.lib.model.PullRequestComment.local_uri = property(lambda self: '{}/federation/mergerequest_comment/{}'.format(APP_URL, self.id))
MergeRequestComment = pagure.lib.model.PullRequestComment

class Repository(pagure.lib.model.Project):
    """
    A ForgeFed Repository.
    """

    @property
    def local_uri(self):
        # This only works for non-forks projects because forks GIT URLs are
        # under "/forks/" whereas the UI shows them under "/fork/". That's why
        # we need to use repository.path, because this returns the actual path
        # in the filesystem that is used by the GIT backend.
        #
        # return '{}.git'.format(super().uri)

        return '{}/{}'.format(APP_URL, super().path)

class TicketTracker(pagure.lib.model.Project):
    """
    A ForgeFed BugTracker.
    """

    @property
    def local_uri(self):
        return '{}/issues'.format(super().local_uri)


################################################################################
# ForgeFed relations.
# These tables are created/added to the Pagure database. They are used for
# various tasks required by federation.
# These relations are purposely devoid of any foreign key to the Pagure database
# in order to keep the plugin as independent and untied as possible to the
# Pagure schema.
################################################################################

class GpgKey(BASE, ActivityStreamObject):
    """
    This class represents an Actor GPG key that is used to sign HTTP POST
    requests.
    """

    __tablename__ = 'forgefed_gpg_key'
    __table_args__ = {
        'comment': 'GPG keys for Actors. Used with HTTP Signature.'
    }

    uri = Column(UnicodeText, primary_key=True, nullable=False,
                 comment='The URI of the key.')

    actor_uri = Column(UnicodeText, nullable=False,
                       comment='The actor who owns the key.')

    private = Column(LargeBinary, nullable=False,
                     comment='The private part of the key.')

    public = Column(LargeBinary, nullable=False,
                    comment='The public part of the key.')

    created = Column(DateTime, nullable=False,
                     default=lambda: datetime.datetime.now(datetime.timezone.utc),
                     comment='When was this key created.')

    @staticmethod
    def new_key():
        """
        Returns a new private/public key pair.
        """

        key = RSA.generate(settings.HTTP_SIGNATURE_KEY_BITS)

        return {
            'private': key.export_key('PEM'),
            'public':  key.publickey().export_key('PEM')
        }

    @staticmethod
    def test_or_set(database, actor_uri, key_uri):
        """
        Test if an Actor already has a GPG key, otherwise automatically generate
        a new one.

        :param database: Since this function is static, we need a working session
        :param actor_uri: URI of the ActivityPub Actor
        :param key_uri: URI of the ActivityPub Key for the Actor
        """

        key = database.query(GpgKey) \
                      .filter(GpgKey.uri == key_uri) \
                      .one_or_none()

        if not key:
            key = GpgKey.new_key()

            database.merge(GpgKey(
                uri       = key_uri,
                actor_uri = actor_uri,
                private   = key['private'],
                public    = key['public']))

            database.flush()

        return key

    @property
    def publickey_pem(self):
        return self.public.decode('UTF-8')

class Collection(BASE, ActivityStreamObject):
    """
    This class represents a list of items for a Collection.
    """

    __tablename__ = 'forgefed_collection'
    __table_args__ = (
        PrimaryKeyConstraint('uri', 'item'),
        {
            'comment': 'Stores the list of items that belong to a Collection.'
        }
    )

    uri = Column(UnicodeText, nullable=False,
                 comment='The URI of the Collection.')

    item = Column(UnicodeText, nullable=False,
                  comment='The URI of an item in the Collection.')

    added = Column(DateTime, nullable=False,
                   default=lambda: datetime.datetime.now(datetime.timezone.utc),
                   comment='When was this item added to the Collection.')

class SameAs(BASE):
    """
    This class is used to map links between local URIs and remote URIs.
    The reason is that we need to create local objects in Pagure to
    represents remote objects such as comments, so we keep track of which
    local objects are only a local representation of remote ones.
    """

    __tablename__ = 'forgefed_sameas'
    __table_args__ = {
        'comment': 'Mappings of local objects to remote objects.'
    }

    local_uri = Column(UnicodeText, primary_key=True, nullable=False)
    remote_uri = Column(UnicodeText, unique=True, nullable=False)

class Resource(BASE):
    """
    This is a relation used for saving copies of JSONLD documents. It stores a URI
    and its corresponding JSON document.
    It's used for storing Activities, as well as caching remote objects that we can
    query quickly without making a GET request (for example when displaying the list
    of Followers, we store the JSONLD document of the remote user).
    """

    __tablename__ = 'forgefed_resource'
    __table_args__ = {
        'comment': 'Stores the JSON-LD document of a URI. Mostly used for caching and for storing Activities.'
    }

    uri = Column(UnicodeText, primary_key=True, default=None,
                 comment='The URI of the object.')

    document = Column(UnicodeText, nullable=False,
                      comment='The JSON-LD document of the object.')

    @property
    def jsonld(self):
        return activitypub.Document(self.document)

class Feed(BASE):
    """
    This class is used to store feeds about federation events. These feeds are
    simply displayed to the user in their federation page.

    NOTE: This only exists because Pagure feed is strictly linked (with a foreign key)
          to existing objects of the database, for example comments ID. Using the
          Pagure feed would require messing with the Pagure schema, therefore I
          decided not to use it. This means there's a separate feed for federation
          events only.
    """

    __tablename__ = 'forgefed_feed'
    __table_args__ = {
        'comment': 'Activities feeds to be displayed to users.'
    }

    # This only exists because SQLAlchemy requires a primary key, but this
    # primary key is not used anywhere.
    id = Column(Integer, primary_key=True)

    actor_uri = Column(UnicodeText, nullable=False,
                       comment='The URI of the local Actor this feed item belongs to.')

    content = Column(UnicodeText, nullable=False,
                     comment='The content of the feed. This is a serialized JSON object.')

    created = Column(DateTime, nullable=False,
                     default=lambda: datetime.datetime.now(datetime.timezone.utc),
                     comment='When the feed item was created.')

    def __init__(self, actor_uri, content):
        self.actor_uri = actor_uri
        if isinstance(content, str):
            self.content = content
        elif isinstance(content, dict):
            self.content = json.dumps(content)


# Automatically create the database tables on startup. The tables are first
# checked for existence before any CREATE TABLE command is issued.
# https://docs.sqlalchemy.org/en/13/orm/tutorial.html#create-a-schema
# TODO This is very useful when developing, but very annoying when in production
#      because it triggers a lot of log.info() messages when pushing with git.

db_url = pagure.config.config.get('DB_URL')
if db_url.startswith('postgres'):
    engine = create_engine(db_url, echo=True, client_encoding="utf8", pool_recycle=3600)
else:
    engine = create_engine(db_url, echo=True, pool_recycle=3600)

BASE.metadata.create_all(engine)
